Repository navigation
Render lock: keep a patch load's flush out of an in-progress render - #1205
Conversation
A patch load settles due deltas on the sending thread (flush_due_deltas before patches_load_patch, so a queued reset lands before the load rebuilds the synth tables). That flush can free oscs -- a released voice's FREE_OSC, or amy_reset_oscs() for a reset -- while the render thread is reading them, because rendering never held a lock. Desktop AddressSanitizer stress (one thread rendering, another reloading synth 1 back to back) crashes in amy_render/render_*/hold_and_modify on main in about 6 runs of 10. Add a second lock, the render lock, taken before the queue lock: - the render thread holds it for a whole block: flush, render, mix (amy_simple_fill_buffer, and the ESP fill task and single-thread path; released before the i2s write, so a load gets in while we wait on DMA); - a patch load holds it from its flush to the end of patches_load_patch, which also keeps a reset run by the render thread's flush from landing halfway through the load's bookkeeping or its clone-on-grow snapshot; - amy_execute_deltas takes it too, covering callers outside a render loop. Ordinary events only take the queue lock, so note-ons never wait for a render. The render lock is recursive per thread via a thread-local depth (a patch string can load a patch: drum kits open with `if3iv1in38Z`), and each platform's lock primitive is now defined once and used for both locks. Not covered: Pico/Teensy have no lock implementation (unchanged no-ops), and tulipcc's native Tulip/AMYboard render loops need the same wrap. Stress test, 10 runs x 3000 reloads under ASan: main 4 clean / 6 render crashes; this change 10 clean. make ctest passes; make test gives the same 90 pass / 43 fail as main here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW4jqUQ4Xc1sdoqzUPL2YV
Measured on an AMYboard (ESP32-S3) with AMY_LOCK_TIMING: holding the render lock across patches_load_patch() held off the render for most of the load, and loads take tens of ms of CPU there: load flush hold render stall longest block Juno 1, 6v 6-9 us 19-30 ms 10-11 ms 15-18 ms DX7 130, 6v 6-8 us 47-71 ms 36-37 ms 43-46 ms grow to 8v 6-9 us 37-88 ms 27-29 ms 34-37 ms drum kit 258 6-8 us 12-13 ms 4-6 ms 8-12 ms against a 5.8 ms block and a ~35 ms DMA ring, so a DX7 load ran the ring dry. The flush is the part that frees oscs; it takes microseconds. Hold the render lock only around it, in the patch-load path and in amy_execute_deltas() (not across the sequencer tick, which can itself load a patch). The render thread still holds it for each whole block, so a flush still can't free oscs mid-render. This gives up protecting the load's bookkeeping from a reset that comes due mid-load (the same exposure main has); that needs a fix that doesn't hold the render off for the length of a load. ASan stress, 10 runs x 3000 reloads: 10 clean (main: 4 clean, 6 render crashes). make ctest passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UW4jqUQ4Xc1sdoqzUPL2YV
|
I'm pretty much entirely relying on Claude for the logic and the solution here. It was verified by tests on AMYboard -checking both that it didn't block normal operation, and that it fixed the osc-freed-mid-load which was the original bug addressed in #1185. |
|
HW CI Bench appears to be blocked; I got Claude to run the same test on my local amyboard which passed and yielded these timings:
|
⛓️ tulipcc integration PR openedThis merge was pinned into tulipcc for full-system CI: shorepine/tulipcc#1383 Test it there and merge that PR to move tulipcc onto this AMY. |
🎛️ AMY HW CI (AMYboard bench)Flashed this PR's AMY (LoadTestChord: 6-voice Juno ✅ PASS — the bench ran the test to completion.
Full chord settled render μs: 2605 (was 2612, Δ -0.3%) (peak 2609, 39 samples) ⬇️ Artifacts: serial log · load trace · report Self-hosted bench (amyboardci). FAIL means only that the test could not run — the load values are informational, with no threshold and no audio compare. See |
Brings in amy up to 1.2.190 (e153f7d): the render lock (shorepine#1205), chained oscs evaluated tail-first (shorepine#1200), MIDI CC output and midi_cc naming an AMY parameter directly (shorepine#1175), a PCM render gain ramp (shorepine#1208), no osc allocation on the ingest path, and the docs that go with them. One conflict, in src/amy.c: upstream grew its own lock abstraction for ESP_PLATFORM (a FreeRTOS mutex behind lock_init/lock_take/lock_give) where we had a bare SemaphoreHandle_t plus esp_rom_sys.h. Took upstream's side, and dropped our a84e093 render-in-flight counter with it: upstream's render lock (f11a552, 2812743) closes the same FREE_OSC-under-render race more thoroughly -- the render thread holds it for a whole block and a patch load holds it across its flush -- and it was measured, where ours spun for up to 50 ms. Nothing else of ours changed; src/amy.c is now identical to upstream/main. f11a552 lists tulipcc's native render loops as needing the wrap themselves, but they do not: every tulipcc path reaches a render through an amy entry point that already takes the lock. The Tab5 sets platform.multithread = 0, so its audio task's amy_render_audio() takes the branch that grabs it around esp_render_on_cores() + amy_fill_buffer(); Tulip CC keeps the default multithread = 1 and renders in amy's own esp_fill_audio_buffer_task; AMYboard-in-VCV pulls blocks with amy_simple_fill_buffer(). All three are wrapped upstream. make ctest: all passed. make test: 135 tests pass, no failures, including our TestAllNotesOffNoteZero at err=-100.0 dB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes a second patch-reload race that #1190 didn't cover. A patch load can free oscillators on the sending thread while the render thread is reading them.
The race. Before
patches_load_patch(),amy_event_to_deltas_queue()callsflush_due_deltas()on whichever thread sent the event. The flush is needed so a queued reset lands before the load rebuilds the synth tables (24adfbc). It can run frees: a released voice'sFREE_OSC, oramy_reset_oscs()for a reset. The render thread holds no lock while it renders, so those frees can hit oscs thatamy_render/render_*/hold_and_modifyare reading.With one thread rendering and another reloading synth 1 back to back,
maincrashes under AddressSanitizer in about 6 runs of 10. Every crash is in the render path, as a heap-use-after-free or a NULLsynth[osc].The fix. A second lock, the render lock, always taken before the queue lock:
amy_simple_fill_buffer()(desktop, web, Godot, pyamy) and both ESP render paths ini2s.c. On ESP it's released before the I2S write, so a waiting load gets in while the fill task waits on DMA.amy_execute_deltas()takes it around its flush, which covers the sample-transfer start inparse.cthat flushes off the render thread.Why only the flush. A first version held the render lock across the whole load. Measured on an AMYboard (ESP32-S3) with a 5.8 ms block and a ~35 ms DMA ring, that stalled the render badly. With the lock narrowed to the flush, the same test shows:
With the narrowed lock, no block waited more than 100 µs for it, and every 500 ms window produced its full 86–87 blocks. That held with background render at ~74% of the block. Loads still take 25–94 ms on the sending thread, as on
main, but the render no longer waits for them. The instrumentation (-DAMY_LOCK_TIMING) and theLockTimingsketch are onclaude/render-lock-timing; they're not part of this PR.Not covered:
mainhas. Protecting it would mean holding the render off for the length of the load, which the table rules out.amy_simple_fill_buffer()ori2s.c. They need the sameamy_grab_render_lock()/amy_release_render_lock()around each block to get the fix.esp_render_task) must not load a patch: it would wait on the fill task's render lock while the fill task waits for it. Nothing in AMY does this.Testing
main), against 4 clean and 6 render-path crashes onmain.total_sampleswithout a lock inamy_sysclock64(), whichmainhas too.make ctestpasses.make testgives the same 90 pass / 43 fail asmainin my environment; the failures are small numeric differences against the reference audio and fail identically onmain.tools/p4_reload_stresswith-DSTRESS_MODE=1(on branchclaude/p4-reload-stress), onmainand on this branch.🤖 Generated with Claude Code
https://claude.ai/code/session_01UW4jqUQ4Xc1sdoqzUPL2YV
Generated by Claude Code